iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
Software Development

Kotlin Lambda 從零開始系列 第 14

Kotlin Lambda 從零開始 Day 14:groupBy / groupingBy — 分組操作

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260807/201219480fj7uFk1cv.jpg

這篇文章會用 TDD 手刻 myGroupBy(含兩個多載版本),再介紹 groupingBy 的 Grouping 介面概念。這是轉換篇的最後一篇

Kotlin ↔ C# 對照表

Kotlin C# LINQ 備註
groupBy { it.department } GroupBy(x => x.Department)
groupBy(keySelector, valueTransform) GroupBy(keySelector, elementSelector)
groupBy { } ToLookup() 都保留完整分組;ToLookup 立即執行,GroupBy 延遲執行
groupingBy { }.eachCount() CountBy() (.NET 9+) 舊寫法 GroupBy().Select(g => g.Count()) 仍會保留完整分組

groupBy vs associateBy

day 12 的 associateBy 和這篇的 groupBy 都是 List → Map 的轉換,但行為完全不同

associateBy:一個 key 對應一個 value。key 重複時 last write wins,前面的被覆蓋

groupBy:一個 key 對應一個 List。key 重複時所有元素都收集起來,不會丟東西

// associateBy:key 重複 → 覆蓋
employees.associateBy { it.department }
// {"Engineering"=Grace, "Marketing"=Diana, "HR"=Frank}
// 只剩最後一個!

// groupBy:key 重複 → 收集
employees.groupBy { it.department }
// {"Engineering"=[Alice, Bob, Grace], "Marketing"=[Charlie, Diana], "HR"=[Eve, Frank]}
// 全部保留

選擇很簡單:確定 key 唯一就用 associateBy,key 會重複就用 groupBy

TDD 實作 myGroupBy

Red:先寫測試

@Test
fun `groupBy numbers by even odd`() {
    val numbers = listOf(1, 2, 3, 4, 5, 6)
    val result = numbers.myGroupBy { if (it % 2 == 0) "even" else "odd" }
    assertEquals(listOf(2, 4, 6), result["even"])
    assertEquals(listOf(1, 3, 5), result["odd"])
}

@Test
fun `groupBy key selector`() {
    val result = employees.myGroupBy { it.department }
    assertEquals(3, result.size)
    assertEquals(3, result["Engineering"]?.size)
    assertEquals(2, result["Marketing"]?.size)
    assertEquals(2, result["HR"]?.size)
}

@Test
fun `groupBy preserves order within groups`() {
    val result = employees.myGroupBy { it.department }
    val engineering = result["Engineering"]!!
    assertEquals("Alice", engineering[0].name)
    assertEquals("Bob", engineering[1].name)
    assertEquals("Grace", engineering[2].name)
}

@Test
fun `groupBy with key and value transform`() {
    val result = employees.myGroupBy(
        keySelector = { it.department },
        valueTransform = { it.name }
    )
    assertEquals(listOf("Alice", "Bob", "Grace"), result["Engineering"])
    assertEquals(listOf("Charlie", "Diana"), result["Marketing"])
}

@Test
fun `groupBy empty list returns empty map`() {
    val empty = emptyList<Employee>()
    val result = empty.myGroupBy { it.department }
    assertEquals(emptyMap<String, List<Employee>>(), result)
}

@Test
fun `groupBy all same key returns single group`() {
    val numbers = listOf(1, 2, 3, 4)
    val result = numbers.myGroupBy { "all" }
    assertEquals(1, result.size)
    assertEquals(listOf(1, 2, 3, 4), result["all"])
}

第三個測試驗證群組內的順序。groupBy 保持原始插入順序,先出現的元素在 List 前面

第四個測試用了帶 valueTransform 的版本。不是把整個 Employee 收集起來,而是只收集 name。回傳 Map<String, List<String>> 而不是 Map<String, List<Employee>>

倒數第二個測試是邊界案例:空 List 進去,回傳空 Map。最後一個測試是另一個邊界,所有元素都對應到同一個 key,結果只有一組,全部收進同一個 List。groupBy 沒有 key 衝突的問題(衝突本來就是它要處理的事),也不會對 null 拋例外,所以這兩個邊界就涵蓋了會出問題的情況

Green:最小實作

inline fun <T, K> Iterable<T>.myGroupBy(keySelector: (T) -> K): Map<K, List<T>> {
    val result = LinkedHashMap<K, MutableList<T>>()
    for (element in this) {
        val key = keySelector(element)
        val list = result.getOrPut(key) { ArrayList() }
        list.add(element)
    }
    return result
}

myAssociate 比,差別在 value 的型別。associate 的 value 是單一元素,groupBy 的 value 是 MutableList<T>

getOrPut 是 Map 的擴充函式:如果 key 已存在就回傳對應的 value,不存在就執行 Lambda 建立一個新值放進去再回傳。這裡 Lambda 是 { ArrayList() },所以第一次遇到某個 key 時會建立空的 ArrayList

有了 list 之後,list.add(element) 把元素加進去。因為 list 是從 map 裡拿出來的參考(reference),修改 list 就等於修改 map 裡的那個 list

帶 valueTransform 的版本差別只在 list.add(valueTransform(element))

inline fun <T, K, V> Iterable<T>.myGroupBy(
    keySelector: (T) -> K,
    valueTransform: (T) -> V
): Map<K, List<V>> {
    val result = LinkedHashMap<K, MutableList<V>>()
    for (element in this) {
        val key = keySelector(element)
        val list = result.getOrPut(key) { ArrayList() }
        list.add(valueTransform(element))
    }
    return result
}

Refactor:往 stdlib 的寫法靠近

getOrPut + add 這個組合已經就是 stdlib 的核心邏輯,沒有可以再簡化的地方。stdlib 唯一多做的是把實作抽成 groupByTo,下一段直接看原始碼

與 stdlib 原始碼比較

原始碼位置:kotlin.collections_Collections.ktkotlin.collectionsGrouping.kt

stdlib 的 groupBy 結構跟我們一樣,也用了 getOrPut

public inline fun <T, K> Iterable<T>.groupBy(
    keySelector: (T) -> K
): Map<K, List<T>> {
    return groupByTo(LinkedHashMap<K, MutableList<T>>(), keySelector)
}

還是那套 xxxTo 模式。groupByTo 裡面就是 getOrPut + add,跟我們的完全一致

Grouping 介面

stdlib 還有一個 groupingBy 函式,回傳的不是 Map 而是 Grouping<T, K> 物件

val grouping = employees.groupingBy { it.department }

Grouping 是一個中間介面,本身不做分組。它記住了原始集合和 keySelector,等你呼叫後面的聚合操作時才真正執行

介面本身小得有點出乎意料,只有兩個方法

public interface Grouping<T, out K> {
    fun sourceIterator(): Iterator<T>
    fun keyOf(element: T): K
}

sourceIterator() 交出原始集合的 Iterator,keyOf() 回答某個元素該歸到哪一組。groupingBy { } 做的事就是回傳一個匿名物件把這兩個方法實作掉,keySelector 被閉包捕捉進去。分組邏輯到這一步都還沒跑

可以接的操作有四個:eachCount()fold()reduce()aggregate()

grouping.eachCount()
// {"Engineering"=3, "Marketing"=2, "HR"=2}

grouping.fold(0) { acc, emp -> acc + emp.salary }
// {"Engineering"=249000, "Marketing"=143000, "HR"=115000}

// reduce 跟 fold 像,但沒有初始值,用每組第一個元素當起點
grouping.reduce { _, acc, emp -> if (emp.salary > acc.salary) emp else acc }
// 每組薪水最高的人

// aggregate 最底層,自己決定每個 key 第一次出現(first 為 true)和後續累積的邏輯
grouping.aggregate { _, acc: Int?, emp, first ->
    if (first) emp.salary else acc!! + emp.salary
}
// 等同上面的 fold,但起始值由你在 first 為 true 時決定

為什麼要多這一層?效率。如果你只想要每組的數量,groupBy { }.mapValues { it.value.size } 會先建立完整的 Map<K, List<T>>(把所有元素都收集起來),再遍歷一次算 size。groupingBy { }.eachCount() 只需要維護一個計數器,不用真的把元素收集為 List

不過對大多數場景來說,groupBy 已經夠用了。groupingBy 在資料量很大、而且你只需要聚合結果(不需要完整的分組列表)時才有明顯優勢

四個操作裡,aggregate() 是最底層的那個,另外三個都是它的專用版本:foldreduce 把「這一組是不是第一次遇到」的判斷包起來,eachCount 則是把累積值固定成一個計數器

這篇不動手實作 myGroupingBy,原因是順序 —— foldreduce 要到 day 17、day 18 才登場,在這裡手刻 Grouping.fold 等於提前把那兩篇的內容講完。等那邊的基礎有了,day 19 會用一整篇把 Grouping 介面連同四個操作一起手刻出來,包括 aggregate 那個「第一次遇到這個 key」的旗標要怎麼判斷才不會被 null 騙到

C# LINQ 走的是不一樣的路。GroupBy(...) 回傳 IEnumerable<IGrouping<K, T>>,每個 IGrouping 自己實作 IEnumerable<T>,要算數量 .Select(g => g.Count()),要 fold .Select(g => g.Aggregate(...))。LINQ 把「分組」跟「聚合」串在同一條 IEnumerable 管道上

但要注意一件常見的誤會:LINQ 的 GroupBy 雖然是 deferred execution,列舉的當下卻會把整個來源緩衝成一個 Lookup,每個 IGrouping 都持有那一組的全部元素。所以 GroupBy().Select(g => g.Count()) 一樣會先建好完整的分組、把每個元素都收進對應的組裡,再去數每組的 size。lazy 在這裡省不掉中間集合的記憶體

groupingBy { }.eachCount() 在這組對照裡比較省。它只維護每個 key 的計數器,不會把元素收集為 List;相較之下,GroupBy().Select(g => g.Count()) 仍會保留各組元素

groupBygroupingBy 的差別呢?groupBy 回傳完整的 Map<K, List<T>>,要算 count 得先把元素收集為 List 再算 size。groupingBy 把工作延後到聚合那一步,eachCount() 全程不建中間 List

C# 這邊補一下時間軸。早年只能用 GroupBy().Select(...Count()),那個寫法確實會先建好完整分組;.NET 9 之後 LINQ 補上了 CountByAggregateByemployees.CountBy(x => x.Department) 就是 groupingBy { }.eachCount() 的直接對應,一樣只維護計數器。所以這組差異現在是「舊寫法 vs 新 API」,不是「Kotlin 有、C# 沒有」

第三部分回顧

轉換篇到這裡結束。從 day 10 到 day 14 走過的路線

map(一對一轉換) → flatMap(一對多展開) → associate(List→Map,key 唯一) → zip/unzip(兩個 List 配對) → groupBy(List→Map,key 重複收集為 List)

這些操作的共同模式:for 迴圈 + 某種容器(ArrayList 或 LinkedHashMap) + Lambda 決定轉換邏輯。結構都很單純,複雜度來自 Lambda 的簽名和回傳型別的變化

本部分 API 速查

這部分一路手刻過的函式,全部列在這裡

函式 篇號 一句話用途
myMap day 10 每個元素套一次 transform,一對一轉成新 List
myMapNotNull day 10 transform 後過濾掉 null,剩下的收集為 List
myMapIndexed day 10 transform 多收一個 index 參數
myFlatMap day 11 每個元素展開成一個集合,再攤平成單層 List
myFlatten day 11 把巢狀的 List<List<T>> 攤平成 List<T>
myAssociate day 12 用 Lambda 產生 key-value pair,組成 Map
myAssociateBy day 12 你選 key,value 是元素本身
myAssociateWith day 12 元素本身當 key,你選 value
myZip day 13 兩個 List 逐位配對成 List<Pair<A, B>>
myUnzip day 13 List<Pair<A, B>> 拆回兩個 List
myGroupBy day 14 同 key 的元素收集為 List,一對多分組

小結

groupByassociateBy 的差別只有一行:key 撞到時是覆蓋還是收集。associateBymap[key] = value 直接蓋掉,groupBygetOrPut(key) { ArrayList() } 拿到那個 key 的 List(第一次遇到就先建一個)再 add。同樣是 List → Map,一個丟資料一個留資料,選錯就是靜靜地少幾筆

groupingBy 則是另一個層次的東西。它不回傳 Map,回傳的是 Grouping 介面,把「怎麼分組」和「分完要做什麼」拆開,所以 eachCount() 可以只數數量、不建出中間的分組 List

轉換篇把「集合進、集合出」的各種變形走過一遍。下一篇進入排序篇,day 15 從 sorted / sortedBy / sortedByDescending 開始。Lambda 的簽名還是 (T) -> R?,但 R 這次多了一個上界 R : Comparable<R> —— 你選出來的鍵得自己知道怎麼比大小

參考資料


Yes


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Lambda 從零開始 Day 13:zip / unzip — 配對與拆分
下一篇
Kotlin Lambda 從零開始 Day 15:sorted / sortedBy / sortedByDescending — 排序基礎
系列文
Kotlin Lambda 從零開始15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言